Skip to content

fix(components): route ui:sidebar-trigger's spread through the form-control DOM declaration - #8660

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-5632-sidebar-trigger-leak
Sep 8, 2026
Merged

fix(components): route ui:sidebar-trigger's spread through the form-control DOM declaration#8660
os-justin merged 2 commits into
mainfrom
claude/issue-5632-sidebar-trigger-leak

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Part of #5632 — one slice, the ui:sidebar-trigger group. The parent burn-down stays OPEN; #5632 is not addressed in full here and remains open, exactly as PR #7564 left it.

The defect, and the second one hiding inside it

ComponentRegistry.register('sidebar-trigger', …) destructured className and spread the rest onto SidebarTrigger, which spreads its own rest onto the Button it renders. So every canary family became an attribute on a real button element. Fourteen of them — one more than the shape this target derives from — and the extra one is the interesting half:

  1. the bare spread the group records.
  2. schema was never taken off the bag. Every other registration in renderers/navigation/sidebar.tsx names it (({ schema, ...props })) because it renders a child list. This one renders none and named only className, so the node SchemaRenderer injects on every render rode the same spread and landed as schema="[object Object]".

That is a second mechanism, not a variant of the group's — which is why this target was ledgered on its own. One filter closes both, because a whitelist never has to enumerate what it drops; re-ledgering schema would have been the wrong repair, and the sweep gate now carries an inverted case that refuses it.

Why the FORM-CONTROL declaration and not the bare toDomProps

The host is a button element, where HTML defines name and disabled. The ledger row is the evidence: it recorded thirteen attributes plus schema and never name, because the shared judge counts an authored name as legitimate on this element. A bare toDomProps would therefore have un-named this control without moving a single number in the gate that grades the change — the exact failure packages/components/src/lib/form-control-dom-props.ts exists to prevent, one host over.

style is forwarded BY NAME (#4435), as every converged sibling in this package does: it reaches the DOM today and the whitelist does not carry it. Dropping it is the plausible wrong fix, and it is pinned (leg E below).

Measured, through the real SchemaRenderer path

Renders-real-markup established before any reading counted: the trigger renders a button element carrying the primitive's own data-sidebar="trigger" hook, a lucide panel-left glyph and the sr-only "Toggle Sidebar" label — asserted as markup, so an error boundary or an early bail cannot satisfy it. Dump written to a file, with a lit control asserted first (a deliberately leaky div must report leaks), because vitest swallows console output from a passing test.

reading before after
leaked attributes 14 0
legitimate attributes 8 8 — identical
class … h-7 w-7 os-sweep-ready unchanged (merged, not clobbered)
toggle fires fires

The 14: ariadescribedby, arialabel, bind, colorvariant, datasource, events, props, reference_to, schema, zzcanary, zzcanarycamel, zzcanarynum, zzcanaryobj, zzcanaryprop — re-derived from LEAK_LEDGER ([...BARE_SPREAD_MINUS_NAME, 'schema'].sort()), not from the card's summary table, and confirmed by measurement.

The 8 legitimate, unchanged in both runs: aria-describedby, aria-label, class, data-obj-id, data-obj-type, data-sidebar, id, name. This is the half a leak gate reports in neither direction — PR #7564's lesson, asked again on this host rather than inherited from the SVG one. On a button element the answer is name, not stroke/fill.

Lesson 1 checked too: this host does not have ui:spinner's clobber shape. The renderer computes no class of its own; SidebarTrigger merges via cn("h-7 w-7", className), and className is destructured so the filtered bag can never become a second writer for it. Both halves are asserted.

Ledger

The row is DELETED, as the two-way exact-set assertion requires. ui:grid stays out. BARE_SPREAD_MINUS_NAME survives as the base for the two groups that still derive from it (action:menu, ui:form). The three meta-cases still hold, and the sweep gains a fourth inverted case (ui:sidebar-trigger is CLEAN — the schema row may not be re-absorbed).

⚠️ The docblock's hand-written counts had already drifted before this slice: it read 95 of 158 / 97 of 181 / 61 clean while the file's own arrays held 90 rows of 159 targets and 69 clean. Re-derived here by parsing those arrays; the class is filed as #8659 and is not addressed in this PR.

Pins added — examples/schema-catalog/test/sidebar-trigger-dom-leak-5632.test.tsx

Five cases, every one observed RED on purpose:

  1. the judge is element-aware about name (the same bag reported differently on a button and on a div)
  2. the full canary set reaches the renderer and none of it reaches the DOM
  3. the legitimate attributes on this host are exactly these — the full set, which is what a "drops everything" filter fails
  4. the trigger still toggles the sidebar, read off the data-state the sidebar itself carries
  5. the declared pass-through channels still arrive, style included

Every case asserts real markup first, and document.cookie is cleared per case because SidebarProvider persists sidebar_state and reads it back on mount (#4234).

Red legs — mutate, prove on disk, run, restore, prove restored

Each leg re-run against the final commit. Restore is git checkout HEAD -- path under an EXIT/INT/TERM trap, proven by an empty git diff HEAD; every leg's mutated blob hash differed from the HEAD blob, so no leg is void. Classified from vitest's JSON reporter.

leg mutation this PR's pin sweep gate
A forward everything (the bug, restored) RED 3/5 RED (leaked 14 non-DOM)
B drop everything (the caricature that must not read as success) RED 2/5 GREEN 203/203
C over-broad filter — the open aria-* / data-* families dropped RED 2/5 GREEN 203/203
D the control rendered inert (disabled) RED 2/5, incl. the toggle case GREEN 203/203
E style no longer forwarded by name RED 1/5 (the style case) GREEN 203/203
F the deleted ledger row put BACK GREEN RED 2/203 — the inverted case and the two-way expiry
G the element-awareness self-check ablated (both arms on a div) RED 1/5 GREEN

Both caricature directions go red on the pin, which is the point: B, C, D and E are invisible to the sweep gate. A filter that strips every attribute, one that strips the ARIA and the designer data-*, one that leaves an inert button, and one that swallows style all read as a perfect zero there. That is the same blind spot #7564 documented on the SVG host, in this host's vocabulary.

Verification

  • pnpm exec vitest run packages/components/ packages/app-shell/ examples/schema-catalog/ scripts/__tests__/body-dialect-census.test.tsTest Files 934 passed (934), Tests 10761 passed | 1 skipped (10762).
  • Final targeted run on the shipped commit — Test Files 3 passed (3), Tests 222 passed (222).
  • pnpm --filter @object-ui/components --filter @object-ui/app-shell --filter @object-ui/example-schema-catalog run type-check — all three Done, on a tree whose dependency closure was built first (an unbuilt tree would have been a precondition, not a result).
  • pnpm exec eslint on the three changed source files — 0 errors, 11 pre-existing no-explicit-any warnings on the index signatures already in the file.
  • node scripts/check-changeset-presence.mjs2 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s): .changeset/5632-sidebar-trigger-dom-passthrough.md. node scripts/check-changeset-no-major.mjsNo changeset declares a major bump.
  • check:control-bytes, check:unreferenced-sources, check:phantom-deps, type-check:coverage, lint:coverage — all exit 0.
  • check:sdui-registration-pins exits 2 with a stated PRECONDITION ("No console build to weigh"), which is NOT MEASURED locally rather than a failure — it needs apps/console built, and this diff adds, removes and renames no registration (11 sidebar-* registrations before and after). Declared to CI.

One repo pin this nearly broke, recorded because it is not obvious

scripts/__tests__/body-dialect-census.test.ts slices this file from register('sidebar-trigger' to EOF and asserts the slice contains neither schema.body nor schema.children — that is how it pins this as the one sidebar-* registration reading no child list. The first draft of the new comment quoted schema.body while explaining why the other registrations destructure schema, and reddened the pin on a prose match. Reworded, and the constraint is now written beside the code. It is also why this fix does not add a schema parameter: the filter drops the key without one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…ontrol DOM declaration

`ui:sidebar-trigger` forwarded its whole prop bag to `SidebarTrigger`, which
spreads its own rest onto the `<button>` it renders — so every authored SDUI key
on the node became an attribute. Fourteen of them, one more than the shape this
target derives from, because this registration also never destructured `schema`:
every other registration in `renderers/navigation/sidebar.tsx` names it (they
need `schema.body`), while this one renders no children and named only
`className`, so the node `SchemaRenderer` injects on every render rode the same
spread and landed as `schema="[object Object]"`. Two mechanisms, one filter —
a whitelist does not have to enumerate what it drops.

The declaration is the form-control one, not the bare `toDomProps`. The host is
a `<button>`, where HTML defines `name`, and the ledger row is the evidence: it
recorded thirteen attributes plus `schema` and never `name`, because the shared
judge counts an authored `name` as legitimate here. A bare `toDomProps` would
have un-named this control without moving one number in the gate that grades it.

`style` is forwarded BY NAME (objectui#4435), as every converged sibling in this
package does — it reaches the DOM today and the whitelist does not carry it.

Measured through the real `SchemaRenderer` path, before and after: the leak set
went 14 -> 0 underneath an UNCHANGED 8-attribute legitimate set (`aria-label`,
`aria-describedby`, `class`, `data-obj-id`, `data-obj-type`, `data-sidebar`,
`id`, `name`), the authored `className` still merges into the primitive's
computed `h-7 w-7` rather than replacing it, and the trigger still toggles the
sidebar.

The ledger row is DELETED, as the two-way exact-set assertion requires, and the
sweep gate gains the inverted case that refuses to let it back in. That takes
the `packages/components` reading to 89 rows in FOUR shapes. The hand-kept
counts in that docblock had drifted (they read 95 of 158 while the tree held 90
rows of 159 targets); they are re-derived here.

Part of #5632

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
…alect census's read

`scripts/__tests__/body-dialect-census.test.ts` slices this file from
`register('sidebar-trigger'` to the end and asserts the slice contains neither
`schema.body` nor `schema.children` — it is how the census pins that this one
`sidebar-*` registration reads no child list. The new comment quoted the first
of those literals while explaining why the OTHER registrations destructure
`schema`, which reddened the pin on a prose match.

Reworded to say the same thing without the literal, and the constraint itself is
now written down beside the code so the next editor does not rediscover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3477.5 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-CY4a9mZG.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.99KB 114.12KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 243.73KB 61.53KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 251.25KB 65.00KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.30KB 56.63KB
plugin-kanban (index.js) 55.44KB 15.73KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-justin
os-justin enabled auto-merge September 8, 2026 20:45
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit ee3b878 Sep 8, 2026
35 checks passed
@os-justin
os-justin deleted the claude/issue-5632-sidebar-trigger-leak branch September 8, 2026 21:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants